--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 1e62ab10b447d4b3c188e7d484317d6dcb1798f7
Parents : 7da4412
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-25T16:29:14-05:00
feat(rngit_tool): introduce RNGit explorer
Changes
4 files changed, 611 insertions(+), 9 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index c01f6e22..43d8448d 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -123,6 +123,7 @@ from meshchatx.src.backend.nomadnet_utils import (
from meshchatx.src.backend.page_node_manager import PageNodeManager
from meshchatx.src.backend.persistent_log_handler import PersistentLogHandler
from meshchatx.src.backend.recovery import CrashRecovery, HealthMonitor
+import meshchatx.src.backend.rngit_tool as rngit_tool
from meshchatx.src.backend.rnprobe_handler import RNProbeHandler
from meshchatx.src.backend.sideband_commands import SidebandCommands
from meshchatx.src.backend.sticker_utils import (
@@ -133,6 +134,7 @@ from meshchatx.src.backend.sticker_utils import (
validate_export_document,
)
from meshchatx.src.backend.telemetry_utils import Telemeter
+from meshchatx.android_push_bridge import _is_chaquopy_android
from meshchatx.src.backend.web_audio_bridge import WebAudioBridge
from meshchatx.src.env_utils import env_bool
from meshchatx.src.path_utils import (
@@ -145,6 +147,15 @@ from meshchatx.src.path_utils import (
from meshchatx.src.ssl_self_signed import generate_ssl_certificate
from meshchatx.src.version import __version__ as app_version
+
+def _truncated_hash32_hex_ok(value: str | None) -> bool:
+ """32 lowercase hex chars (Reticulum truncated hash) without relying on live RNS constants."""
+ n = normalize_hex_identifier(value or "")
+ if len(n) != 32:
+ return False
+ return hex_identifier_to_bytes(n) is not None
+
+
# Global log handler
memory_log_handler = PersistentLogHandler()
log_dir = resolve_log_dir()
@@ -2862,11 +2873,15 @@ class ReticulumMeshChat:
self.voicemail_manager.handle_incoming_call(caller_identity)
print(f"on_incoming_telephone_call: {caller_identity.hash.hex()}")
+ ch = caller_identity.hash.hex()
+ caller_name = (self.get_name_for_identity_hash(ch) or "").strip() or "Mesh"
AsyncUtils.run_async(
self.websocket_broadcast(
json.dumps(
{
"type": "telephone_ringing",
+ "remote_identity_hash": ch,
+ "remote_identity_name": caller_name,
},
),
),
@@ -4506,7 +4521,11 @@ class ReticulumMeshChat:
# set required RNodeInterface options
interface_details["port"] = interface_port
- interface_details["frequency"] = interface_frequency
+ interface_details["frequency"] = (
+ InterfaceEditor.coerce_rnode_frequency_hz(
+ interface_frequency,
+ )
+ )
interface_details["bandwidth"] = interface_bandwidth
interface_details["txpower"] = interface_txpower
interface_details["spreadingfactor"] = interface_spreadingfactor
@@ -4595,7 +4614,9 @@ class ReticulumMeshChat:
sub_interface_name = sub_interface.get("name")
interface_details[sub_interface_name] = {
"interface_enabled": "true",
- "frequency": int(sub_interface["frequency"]),
+ "frequency": InterfaceEditor.coerce_rnode_frequency_hz(
+ sub_interface["frequency"],
+ ),
"bandwidth": int(sub_interface["bandwidth"]),
"txpower": int(sub_interface["txpower"]),
"spreadingfactor": int(sub_interface["spreadingfactor"]),
@@ -4853,6 +4874,23 @@ class ReticulumMeshChat:
if "enabled" in interface_config[interface_name]:
del interface_config[interface_name]["enabled"]
+ iface_body = interface_config[interface_name]
+ iface_type = iface_body.get("type")
+ if iface_type in ("RNodeInterface", "RNodeIPInterface"):
+ freq = iface_body.get("frequency")
+ if freq is not None and freq != "":
+ iface_body["frequency"] = (
+ InterfaceEditor.coerce_rnode_frequency_hz(freq)
+ )
+ elif iface_type == "RNodeMultiInterface":
+ for _sub_key, sub in list(iface_body.items()):
+ if isinstance(sub, dict):
+ freq = sub.get("frequency")
+ if freq is not None and freq != "":
+ sub["frequency"] = (
+ InterfaceEditor.coerce_rnode_frequency_hz(freq)
+ )
+
# update reticulum config with new interfaces
interfaces = self._get_interfaces_section()
interfaces.update(interface_config)
@@ -4921,8 +4959,11 @@ class ReticulumMeshChat:
)
await websocket_response.prepare(request)
- # Early guard on config
- if not self.web_audio_bridge.config_enabled():
+ # Chaquopy Android has no LXST host audio device; always allow the websocket bridge.
+ web_audio_allowed = (
+ self.web_audio_bridge.config_enabled() or _is_chaquopy_android()
+ )
+ if not web_audio_allowed:
await websocket_response.send_str(
json.dumps(
{"type": "error", "message": "Web audio is disabled in config"},
@@ -6618,7 +6659,8 @@ class ReticulumMeshChat:
self.config.telephone_web_audio_enabled,
"get",
lambda: False,
- )(),
+ )()
+ or _is_chaquopy_android(),
"allow_fallback": getattr(
self.config.telephone_web_audio_allow_fallback,
"get",
@@ -7670,8 +7712,8 @@ class ReticulumMeshChat:
for row in db_custom_names:
custom_names[row["destination_hash"]] = row["display_name"]
- # If we're looking for telephony announces, pre-fetch LXMF announces for the same identities
- if aspect == "lxst.telephony":
+ # Pre-fetch LXMF display names by identity (telephony + rngit heard list).
+ if aspect in ("lxst.telephony", rngit_tool.RNGIT_ANNOUNCE_ASPECT):
identity_hashes = list(
{r["identity_hash"] for r in results if r.get("identity_hash")},
)
@@ -7716,6 +7758,14 @@ class ReticulumMeshChat:
display_name = lxmf_names_for_telephony.get(
announce["identity_hash"],
)
+ elif announce["aspect"] == rngit_tool.RNGIT_ANNOUNCE_ASPECT:
+ display_name = rngit_tool.display_name_from_rngit_app_data(
+ announce.get("app_data"),
+ )
+ if not display_name or display_name == "Anonymous Peer":
+ cand = lxmf_names_for_telephony.get(announce["identity_hash"])
+ if cand and cand != "Anonymous Peer":
+ display_name = cand
if not display_name or display_name == "Anonymous Peer":
if is_local and self.current_context:
@@ -8718,6 +8768,70 @@ class ReticulumMeshChat:
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
+ @routes.post("/api/v1/rngit-tool/probe")
+ async def rngit_tool_probe(request):
+ if not getattr(self, "reticulum", None) or not self.current_context:
+ return web.json_response(
+ {"message": "Reticulum not ready"},
+ status=503,
+ )
+ try:
+ data = await request.json()
+ except Exception:
+ return web.json_response({"message": "Invalid JSON"}, status=400)
+
+ destination_hash_str = (data.get("destination_hash") or "").strip()
+ group_name = (data.get("group_name") or "").strip()
+ raw_names = data.get("repository_names")
+ if isinstance(raw_names, list):
+ name_list = [str(x).strip() for x in raw_names if str(x).strip()]
+ else:
+ text = (data.get("repository_names_text") or raw_names or "").strip()
+ name_list = rngit_tool.parse_repository_name_lines(text)
+
+ path_timeout = data.get("path_timeout")
+ link_timeout = data.get("link_timeout")
+ list_timeout = data.get("list_timeout")
+ for_push = bool(data.get("for_push", False))
+
+ try:
+ pto = float(path_timeout) if path_timeout is not None else None
+ except (TypeError, ValueError):
+ pto = None
+ try:
+ lto = float(link_timeout) if link_timeout is not None else None
+ except (TypeError, ValueError):
+ lto = None
+ try:
+ rto = float(list_timeout) if list_timeout is not None else None
+ except (TypeError, ValueError):
+ rto = None
+
+ result = await rngit_tool.probe_repositories(
+ self.current_context.identity,
+ destination_hash_str,
+ group_name,
+ name_list,
+ path_timeout=pto,
+ link_timeout=lto,
+ list_timeout=rto,
+ for_push=for_push,
+ )
+ if not result.get("ok"):
+ err = result.get("error", "unknown")
+ status = (
+ 400
+ if err
+ in (
+ "invalid_destination_hash",
+ "invalid_group_name",
+ "no_repository_names",
+ )
+ else 502
+ )
+ return web.json_response(result, status=status)
+ return web.json_response(result)
+
# --- Page Node API ---
@routes.get("/api/v1/page-nodes")
@@ -9377,6 +9491,9 @@ class ReticulumMeshChat:
destination_hash,
display_name,
)
+ self.websocket_rebroadcast_rngit_after_custom_destination_display_name_change(
+ destination_hash,
+ )
return web.json_response(
{
"message": "Custom display name has been updated",
@@ -9385,6 +9502,9 @@ class ReticulumMeshChat:
# otherwise remove display name
self.database.announces.delete_custom_display_name(destination_hash)
+ self.websocket_rebroadcast_rngit_after_custom_destination_display_name_change(
+ destination_hash,
+ )
return web.json_response(
{
"message": "Custom display name has been removed",
@@ -13927,6 +14047,10 @@ class ReticulumMeshChat:
)
elif announce["aspect"] == "lxst.telephony":
display_name = parse_lxmf_display_name(announce["app_data"])
+ elif announce["aspect"] == rngit_tool.RNGIT_ANNOUNCE_ASPECT:
+ display_name = rngit_tool.display_name_from_rngit_app_data(
+ announce.get("app_data")
+ )
# Try to find associated LXMF destination hash if this is a telephony announce
lxmf_destination_hash = None
@@ -13971,6 +14095,38 @@ class ReticulumMeshChat:
except Exception:
pass
+ if (
+ announce.get("aspect") == rngit_tool.RNGIT_ANNOUNCE_ASPECT
+ and announce.get("identity_hash")
+ and (not display_name or display_name == "Anonymous Peer")
+ ):
+ lxmf_announces = self.database.announces.get_filtered_announces(
+ aspect="lxmf.delivery",
+ search_term=announce["identity_hash"],
+ )
+ if lxmf_announces:
+ for lxmf_a in lxmf_announces:
+ if lxmf_a["identity_hash"] == announce["identity_hash"]:
+ cand = parse_lxmf_display_name(lxmf_a["app_data"])
+ if cand and cand != "Anonymous Peer":
+ display_name = cand
+ break
+
+ if not display_name or display_name == "Anonymous Peer":
+ is_local = (
+ self.current_context
+ and announce.get("identity_hash") == self.current_context.identity_hash
+ )
+ if is_local and self.current_context:
+ display_name = self.current_context.config.display_name.get()
+ elif announce.get("identity_hash"):
+ display_name = (
+ self.get_name_for_identity_hash(announce["identity_hash"])
+ or "Anonymous Peer"
+ )
+ else:
+ display_name = "Anonymous Peer"
+
# find lxmf user icon from database
lxmf_user_icon = None
# Try multiple potential hashes for the icon
@@ -15486,6 +15642,11 @@ class ReticulumMeshChat:
),
)
+ self.websocket_rebroadcast_rngit_for_identity_hashes(
+ {identity_hash},
+ context=ctx,
+ )
+
# resend all failed messages that were intended for this destination
if ctx.config.auto_resend_failed_messages_when_announce_received.get():
AsyncUtils.run_async(
@@ -15635,6 +15796,137 @@ class ReticulumMeshChat:
except Exception as e:
print("Error resending failed message: " + str(e))
+ def websocket_rebroadcast_rngit_for_identity_hashes(
+ self,
+ identity_hashes: set[str] | list[str] | None,
+ context=None,
+ ):
+ """Push stored ``git.repositories`` rows again so UIs pick up new derived display names."""
+ ctx = context or self.current_context
+ if not ctx or not ctx.database or not identity_hashes:
+ return
+ seen_dest: set[str] = set()
+ for raw_ih in identity_hashes:
+ if not raw_ih:
+ continue
+ ih = normalize_hex_identifier(raw_ih)
+ if not _truncated_hash32_hex_ok(ih):
+ continue
+ rows = ctx.database.announces.get_filtered_announces(
+ aspect=rngit_tool.RNGIT_ANNOUNCE_ASPECT,
+ identity_hash=ih,
+ limit=500,
+ offset=0,
+ )
+ for row in rows:
+ dh = row.get("destination_hash")
+ if not dh or dh in seen_dest:
+ continue
+ seen_dest.add(dh)
+ announce = ctx.database.announces.get_announce_by_hash(dh)
+ if not announce:
+ continue
+ ad = dict(announce) if not isinstance(announce, dict) else announce
+ AsyncUtils.run_async(
+ self.websocket_broadcast(
+ json.dumps(
+ {
+ "type": "announce",
+ "announce": self.convert_db_announce_to_dict(ad),
+ },
+ ),
+ ),
+ )
+
+ def websocket_rebroadcast_rngit_after_custom_destination_display_name_change(
+ self,
+ destination_hash_raw: str,
+ context=None,
+ ):
+ """Re-send rngit rows when a custom name is set on the rngit hash or the peer's LXMF hash."""
+ ctx = context or self.current_context
+ if not ctx or not ctx.database:
+ return
+ dh = normalize_hex_identifier(destination_hash_raw)
+ if not _truncated_hash32_hex_ok(dh):
+ return
+ row = ctx.database.announces.get_announce_by_hash(dh)
+ if not row:
+ return
+ rd = dict(row) if not isinstance(row, dict) else row
+ aspect = rd.get("aspect")
+ if aspect == rngit_tool.RNGIT_ANNOUNCE_ASPECT:
+ AsyncUtils.run_async(
+ self.websocket_broadcast(
+ json.dumps(
+ {
+ "type": "announce",
+ "announce": self.convert_db_announce_to_dict(rd),
+ },
+ ),
+ ),
+ )
+ elif aspect == "lxmf.delivery":
+ ih = rd.get("identity_hash")
+ if ih:
+ self.websocket_rebroadcast_rngit_for_identity_hashes(
+ {ih},
+ context=ctx,
+ )
+
+ def on_rngit_repositories_announce_received(
+ self,
+ aspect,
+ destination_hash,
+ announced_identity,
+ app_data,
+ announce_packet_hash,
+ context=None,
+ ):
+ """Handle rngit ``git.repositories`` announces (same storage path as other aspects)."""
+ ctx = context or self.current_context
+ if not ctx or not ctx.running or not ctx.announce_manager or not ctx.database:
+ return
+
+ identity_hash = announced_identity.hash.hex()
+ if self.is_destination_blocked(identity_hash, context=ctx):
+ print(f"Dropping rngit announce from blocked source: {identity_hash}")
+ if hasattr(self, "reticulum") and self.reticulum:
+ self.reticulum.drop_path(destination_hash)
+ return
+
+ print(
+ "Received an announce from "
+ + RNS.prettyhexrep(destination_hash)
+ + " for [git.repositories]",
+ )
+
+ self.announce_timestamps.append(time.time())
+
+ ctx.announce_manager.upsert_announce(
+ self.reticulum,
+ announced_identity,
+ destination_hash,
+ aspect,
+ app_data,
+ announce_packet_hash,
+ )
+
+ announce = ctx.database.announces.get_announce_by_hash(destination_hash.hex())
+ if not announce:
+ return
+
+ AsyncUtils.run_async(
+ self.websocket_broadcast(
+ json.dumps(
+ {
+ "type": "announce",
+ "announce": self.convert_db_announce_to_dict(announce),
+ },
+ ),
+ ),
+ )
+
def on_nomadnet_node_announce_received(
self,
aspect,
diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py
index 71842e2c..7cb2ddc2 100644
--- a/meshchatx/src/backend/identity_context.py
+++ b/meshchatx/src/backend/identity_context.py
@@ -460,6 +460,19 @@ class IdentityContext:
)
),
),
+ AnnounceHandler(
+ "git.repositories",
+ lambda aspect, dh, ai, ad, aph: (
+ self.app.on_rngit_repositories_announce_received(
+ aspect,
+ dh,
+ ai,
+ ad,
+ aph,
+ context=self,
+ )
+ ),
+ ),
]
for handler in handlers:
RNS.Transport.register_announce_handler(handler)
diff --git a/meshchatx/src/backend/rngit_tool.py b/meshchatx/src/backend/rngit_tool.py
new file mode 100644
index 00000000..38638668
--- /dev/null
+++ b/meshchatx/src/backend/rngit_tool.py
@@ -0,0 +1,296 @@
+# SPDX-License-Identifier: 0BSD
+
+"""RNGit explorer: resolve paths and run ``/git/list`` for candidate repo names."""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import contextlib
+import re
+from typing import Any
+
+import RNS
+
+RNGIT_ANNOUNCE_ASPECT = "git.repositories"
+
+RNGIT_APP_NAME = "git"
+RNGIT_ASPECT = "repositories"
+RNGIT_PATH_LIST = "/git/list"
+RNGIT_IDX_REPOSITORY = 0x00
+
+_MAX_PROBE_NAMES = 64
+_MAX_REFS_PREVIEW_CHARS = 8000
+
+
+def display_name_from_rngit_app_data(app_data_b64: str | None) -> str | None:
+ """Decode optional rngit announce app_data (often a short node label)."""
+ if not app_data_b64:
+ return None
+ try:
+ raw = base64.b64decode(app_data_b64)
+ except Exception:
+ return None
+ if not raw:
+ return None
+ text = raw.decode("utf-8", errors="replace").strip()
+ if not text:
+ return None
+ line = text.splitlines()[0].strip()
+ if not line:
+ return None
+ return line[:120]
+
+
+def normalize_destination_hash_hex(value: str) -> str | None:
+ raw = value.strip().lower().replace(":", "")
+ if len(raw) != RNS.Reticulum.TRUNCATED_HASHLENGTH // 4:
+ return None
+ try:
+ bytes.fromhex(raw)
+ except ValueError:
+ return None
+ return raw
+
+
+def slug_segment(name: str) -> str | None:
+ s = name.strip()
+ if not s or len(s) > 256:
+ return None
+ if not re.fullmatch(r"[A-Za-z0-9._-]+", s):
+ return None
+ return s
+
+
+def parse_repository_name_lines(
+ text: str, *, limit: int = _MAX_PROBE_NAMES
+) -> list[str]:
+ out: list[str] = []
+ for line in text.splitlines():
+ s = line.strip()
+ if not s or s.startswith("#"):
+ continue
+ if len(out) >= limit:
+ break
+ out.append(s)
+ return out
+
+
+def clone_command(destination_hash_hex: str, group: str, repository: str) -> str:
+ return f"git clone rns://{destination_hash_hex}/{group}/{repository}"
+
+
+async def list_remote_git_refs(
+ identity: RNS.Identity,
+ destination_hash_hex: str,
+ group_name: str,
+ repository_name: str,
+ *,
+ path_timeout: float | None = None,
+ link_timeout: float | None = None,
+ list_timeout: float | None = None,
+ for_push: bool = False,
+) -> dict[str, Any]:
+ """Open an rngit link and perform ``/git/list`` for ``group_name/repository_name``."""
+ norm_hash = normalize_destination_hash_hex(destination_hash_hex)
+ if not norm_hash:
+ return {"ok": False, "error": "invalid_destination_hash"}
+ group = slug_segment(group_name)
+ repo = slug_segment(repository_name)
+ if not group:
+ return {"ok": False, "error": "invalid_group_name"}
+ if not repo:
+ return {"ok": False, "error": "invalid_repository_name"}
+
+ path_timeout = (
+ path_timeout if path_timeout is not None else RNS.Transport.PATH_REQUEST_TIMEOUT
+ )
+ link_timeout = link_timeout if link_timeout is not None else 30.0
+ list_timeout = list_timeout if list_timeout is not None else 120.0
+
+ destination_hash = bytes.fromhex(norm_hash)
+
+ loop = asyncio.get_running_loop()
+ if not RNS.Transport.has_path(destination_hash):
+ RNS.Transport.request_path(destination_hash)
+
+ deadline = loop.time() + path_timeout
+ while not RNS.Transport.has_path(destination_hash) and loop.time() < deadline:
+ await asyncio.sleep(0.1)
+
+ if not RNS.Transport.has_path(destination_hash):
+ return {"ok": False, "error": "path_not_found"}
+
+ remote_identity = RNS.Identity.recall(destination_hash)
+ if not remote_identity:
+ return {"ok": False, "error": "identity_not_found"}
+
+ destination = RNS.Destination(
+ remote_identity,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ RNGIT_APP_NAME,
+ RNGIT_ASPECT,
+ )
+ link = RNS.Link(destination)
+
+ link_ready = False
+ link_failed = False
+ done_event = asyncio.Event()
+ response_holder: dict[str, Any] = {}
+
+ def on_established(lnk: RNS.Link):
+ nonlocal link_ready, link_failed
+ try:
+ lnk.identify(identity)
+ link_ready = True
+ except Exception:
+ link_failed = True
+
+ def on_closed(_lnk: RNS.Link):
+ nonlocal link_failed
+ if not link_ready:
+ link_failed = True
+
+ link.set_link_established_callback(on_established)
+ link.set_link_closed_callback(on_closed)
+
+ try:
+ deadline = loop.time() + link_timeout
+ while not link_ready and not link_failed and loop.time() < deadline:
+ await asyncio.sleep(0.1)
+
+ if not link_ready or link_failed:
+ return {"ok": False, "error": "link_failed"}
+
+ repo_path = f"{group}/{repo}"
+ request_data: dict[str, Any] = {
+ RNGIT_IDX_REPOSITORY: repo_path,
+ "for_push": bool(for_push),
+ }
+
+ def on_response(request_receipt):
+ response_holder["response"] = getattr(request_receipt, "response", None)
+ loop.call_soon_threadsafe(done_event.set)
+
+ def on_failed(_request_receipt=None):
+ response_holder["response"] = None
+ loop.call_soon_threadsafe(done_event.set)
+
+ receipt = link.request(
+ RNGIT_PATH_LIST,
+ request_data,
+ response_callback=on_response,
+ failed_callback=on_failed,
+ timeout=list_timeout,
+ )
+ if receipt is False:
+ return {"ok": False, "error": "request_not_sent"}
+
+ try:
+ await asyncio.wait_for(done_event.wait(), timeout=list_timeout + 5.0)
+ except TimeoutError:
+ return {"ok": False, "error": "list_timeout"}
+
+ response = response_holder.get("response")
+ if response is None or not isinstance(response, (bytes, bytearray)):
+ return {"ok": False, "error": "invalid_response"}
+
+ body = bytes(response)
+ if len(body) < 1:
+ return {"ok": False, "error": "empty_response"}
+
+ status_byte = body[0]
+ payload = body[1:]
+ if status_byte != 0:
+ return {
+ "ok": False,
+ "error": payload.decode("utf-8", errors="replace").strip()
+ or "server_refused",
+ }
+
+ return {"ok": True, "refs": payload.decode("utf-8", errors="replace")}
+ finally:
+ with contextlib.suppress(Exception):
+ link.teardown()
+
+
+async def probe_repositories(
+ identity: RNS.Identity,
+ destination_hash_hex: str,
+ group_name: str,
+ repository_names: list[str],
+ *,
+ path_timeout: float | None = None,
+ link_timeout: float | None = None,
+ list_timeout: float | None = None,
+ for_push: bool = False,
+ max_preview_chars: int = _MAX_REFS_PREVIEW_CHARS,
+) -> dict[str, Any]:
+ """Try ``/git/list`` for each repository name; used when rngit has no index endpoint."""
+ norm = normalize_destination_hash_hex(destination_hash_hex)
+ if not norm:
+ return {"ok": False, "error": "invalid_destination_hash", "results": []}
+ group = slug_segment(group_name)
+ if not group:
+ return {"ok": False, "error": "invalid_group_name", "results": []}
+
+ trimmed = [n.strip() for n in repository_names if n.strip()][:_MAX_PROBE_NAMES]
+ if not trimmed:
+ return {"ok": False, "error": "no_repository_names", "results": []}
+
+ results: list[dict[str, Any]] = []
+ for raw in trimmed:
+ repo = slug_segment(raw)
+ if not repo:
+ results.append(
+ {
+ "repository": raw,
+ "reachable": False,
+ "error": "invalid_repository_name",
+ "clone_command": None,
+ },
+ )
+ continue
+
+ listed = await list_remote_git_refs(
+ identity,
+ norm,
+ group,
+ repo,
+ path_timeout=path_timeout,
+ link_timeout=link_timeout,
+ list_timeout=list_timeout,
+ for_push=for_push,
+ )
+ cmd = clone_command(norm, group, repo)
+ if listed.get("ok"):
+ refs = listed.get("refs") or ""
+ results.append(
+ {
+ "repository": repo,
+ "reachable": True,
+ "refs_preview": refs[:max_preview_chars],
+ "refs_truncated": len(refs) > max_preview_chars,
+ "clone_command": cmd,
+ "error": None,
+ },
+ )
+ else:
+ results.append(
+ {
+ "repository": repo,
+ "reachable": False,
+ "refs_preview": None,
+ "refs_truncated": False,
+ "clone_command": cmd,
+ "error": listed.get("error"),
+ },
+ )
+
+ return {
+ "ok": True,
+ "destination_hash": norm,
+ "group_name": group,
+ "results": results,
+ }
diff --git a/meshchatx/src/version.py b/meshchatx/src/version.py
index cadbe5fd..9fee25d2 100644
--- a/meshchatx/src/version.py
+++ b/meshchatx/src/version.py
@@ -1,5 +1,6 @@
-"""Version string synced from package.json. Do not edit by hand.
-Run: pnpm run version:sync
+"""Version string synced from package.json.
+
+Do not edit by hand. Run: pnpm run version:sync
"""
__version__ = "4.6.0"
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────